Data Masters: Cientista de Dados

Author

Felipe Viacava

Published

August 15, 2023

Introdução

Nesta apresentação

  1. Análise exploratória

  2. Modelo de classificação e Rankeamento

  3. Análise de agrupamentos

Scripts de auxílio

Repositório completo: GitHub

  • Análise exploratória completa: 01-eda.ipynb
  • Modelos de classificação e rankeamento: 02-class.ipynb
  • Análise de agrupamentos: 03-cluster.ipynb
  • Wrapper de pipelines do sklearn: resources/train_evaluate.py
  • Transformadores customizados: resources/customtransformers.py
  • Pipelines de processamento de dados: resources/prep.py
  • Separação entre treino e teste: resources/split.py
  • Treinamento da Random Forest: 11-rf.py
  • Treinamento do HGB: 12-hgb.py
  • Treinar os modelos de classificação run_train.sh
  • Vizualização de dados: resources/customviz.py
  • Funções de auxílio da EDA: resources/edautils.py

Premissas

  • Os dados fornecidos são suficientes para a solução do problema
  • Objetivo da classificação: maximizar lucro
  • Objetivo do rankeamento: maximizar lucro atuando apenas sobre o rank 1
  • Objetivo do agrupamento: identificar clusters e avaliá-los com base no lucro obtido na classificação

1 - Análise exploratória

  • Separação prévia entre treino e teste
  • Nenhuma alteração definitiva nos dados
  • Auxílio na tomada de decisão para a classificação, o rankeamento e o agrupammento da base de clientes

Análise inicial

  • Colunas constantes
  • Colunas duplicadas
  • Sem valores faltantes
Mostrar/esconder código
dcc = DropConstantColumns(print_cols=True)
dcc = dcc.fit(df)
df = dcc.transform(df)

ddc = DropDuplicateColumns(print_cols=True)
ddc = ddc.fit(df)
df = ddc.transform(df)

print(f"{df.isna().sum().sum()} missing values found.")
54 constant columns were found.
23 duplicate columns were found.
0 missing values found.
Source: 01-eda.ipynb

Transformadores customizados (exemplo)

  • Implementação em pipelines de processamento
  • Uso simples e fácil manutenção
  • Reutilização em outros projetos
Mostrar/esconder código
# Este código é apenas uma reprodução do script original.
from sklearn.base import BaseEstimator, TransformerMixin
from typing import Union
import pandas as pd
import numpy as np

class DropConstantColumns(BaseEstimator, TransformerMixin):
    """
    This class is made to work as a step in sklearn.pipeline.Pipeline object.
    It drops constant columns from a pandas dataframe object.
    Important: the constant columns are found in the fit function and dropped in the transform function.
    """
    def __init__(self, print_cols: bool = False, thresh: float = None, search: Union[float, int] = None, ignore_prefix: list[str] = [], also: list[str] = []) -> None:
        """
        print_cols: default = False. Determine whether the fit function should print the constant columns' names.
        thresh: default = None. If any value occurs more than this fraction of the total number of rows, the column is considered constant.
        Initiates the class.
        """
        self.print_cols = print_cols
        self.also = also
        self.thresh = thresh
        self.ignore_prefix = ignore_prefix
        self.search = search
        pass

    def fit(self, X: pd.DataFrame , y: None = None) -> None:
        """
        X: dataset whose constant columns should be removed.
        y: Shouldn't be used. Only exists to prevent raise Exception due to accidental input in a pipeline.
        Creates class atributte with the names of the columns to be removed in the transform function.
        """
        if self.thresh is None:
            self.constant_cols = [
                col
                for col in X.columns
                if (
                    ((X[col].nunique() == 1) | (col in self.also))
                    & ~any([col.startswith(prefix) for prefix in self.ignore_prefix])
                )
            ]
        elif self.search is None:
            self.constant_cols = [
                col
                for col in X.columns
                if (
                    (X[col].value_counts(normalize=True).max() > self.thresh)
                    & ~any([col.startswith(prefix) for prefix in self.ignore_prefix])
                )
            ]
        else:
            self.constant_cols = [
                col
                for col in X.columns
                if (
                    ((X[col]==self.search).sum()/X.shape[0] > self.thresh)
                    & (~any([col.startswith(prefix) for prefix in self.ignore_prefix]))
                )
            ]

        if self.print_cols:
            print(f"{len(self.constant_cols)} constant columns were found.")
        return self
    
    def transform(self, X: pd.DataFrame) -> pd.DataFrame:
        """
        X: dataset whose constant columns should be removed.
        Returns dataset without the constant columns found in the fit function.
        """
        return X.copy().drop(self.constant_cols, axis=1)

class DropDuplicateColumns(BaseEstimator, TransformerMixin):
    """
    This class is made to work as a step in sklearn.pipeline.Pipeline object.
    It drops duplicate columns from a pandas dataframe object.
    Important: the duplicate columns are found in the fit function and dropped in the transform function.
    """
    def __init__(self, print_cols: bool = False, ignore: list[str] = []) -> None:
        """
        print_cols: default = False. Determine whether the fit function should print the duplicate columns' names.
        ignore: list of columns to ignore.
        Initiates the class.
        """
        self.print_cols = print_cols
        self.ignore = ignore
        pass

    def fit(self, X: pd.DataFrame, y: None = None) -> None:
        """
        X: dataset whose duplicate columns should be removed.
        y: Shouldn't be used. Only exists to prevent raise Exception due to accidental input in a pipeline.
        Creates class atributte with the names of the columns to be removed in the transform function.
        """
        regular_columns = []
        duplicate_columns = []
        sorted_cols = sorted(X.columns)
        for col0 in sorted_cols:
            if col0 not in duplicate_columns:
                regular_columns.append(col0)
            for col1 in sorted_cols:
                if (col0 != col1):
                    if X[col0].equals(X[col1]):
                        if col1 not in regular_columns:
                            duplicate_columns.append(col1)
        self.duplicate_cols = duplicate_columns
        if self.print_cols:
            print(f"{len(duplicate_columns)} duplicate columns were found.")
        return self
    
    def transform(self, X: pd.DataFrame) -> pd.DataFrame:
        """
        X: dataset whose duplicate columns should be removed.
        Returns dataset without the duplicate columns found in the fit function.
        """ 
        X_ = X.copy()
        return X_.drop(self.duplicate_cols, axis=1)

class AddNonZeroCount(BaseEstimator, TransformerMixin):
    """
    This class is made to work as a step in sklearn.pipeline.Pipeline object.
    """
    def __init__(self, prefix: str = "", ignore: list[str] = []) -> None:
        """
        prefix: prefix of the columns to be summed.
        ignore: list of columns to ignore.
        fake_value: value to be replaced with None.
        Initiates de class.
        """
        self.prefix = prefix
        self.ignore = ignore
        pass

    def fit(self, X: pd.DataFrame, y: None = None) -> None:
        """
        X: dataset whose "prefix" variables different than 0 should be counted.
        y: Shouldn't be used. Only exists to prevent raise Exception due to accidental input in a pipeline.
        Creates class atributte with the names of the columns whose not 0 values should be counted in the transform function.
        """
        self.prefix_cols = [
            col
            for col in X.columns
            if (
                    (col.startswith(self.prefix))
                    & (col not in self.ignore)
            )
        ]
        return self
    
    def transform(self, X: pd.DataFrame) -> pd.DataFrame:
        """
        X: dataset whose "prefix" variables' not 0 values should be counted.
        Returns dataset with new column with the count of the "prefix" variables' not 0 values.
        """  
        X_ = X.copy()
        X_[f"non_zero_count_{self.prefix}"] = X_[self.prefix_cols] \
            .applymap(lambda x: 0 if ((x == 0) | (x == None)) else 1) \
            .sum(axis=1)
        return X_

class CustomSum(BaseEstimator, TransformerMixin):
    """
    This class is made to work as a step in sklearn.pipeline.Pipeline object.
    It sums columns from a pandas dataframe object based on the columns prefix.
    """
    def __init__(self, prefix: str = "", ignore: list[str] = []) -> None:
        """
        prefix: prefix of the columns to be summed.
        ignore: list of columns to ignore.
        fake_value: value to be replaced with None.
        Initiates de class.
        """
        self.prefix = prefix
        self.ignore = ignore
        pass

    def fit(self, X: pd.DataFrame, y: None = None) -> None:
        """
        X: dataset whose columns with "prefix" should be summed.
        y: Shouldn't be used. Only exists to prevent raise Exception due to accidental input in a pipeline.
        Creates class atributte with the names of the columns to be summed in the transform function.
        """
        self.prefix_cols = [
            col
            for col in X.columns
            if (
                    (col.startswith(self.prefix))
                    & (col not in self.ignore)
            )
        ]
        return self
    
    def transform(self, X: pd.DataFrame) -> pd.DataFrame:
        """
        X: dataset whose "prefix" variables should be summed.
        Returns dataset with new column with the sum of the "prefix" variables.
        """  
        X_ = X.copy()
        X_[f"sum_of_{self.prefix}"] = X_[self.prefix_cols] \
            .sum(axis=1)
        return X_

class CustomImputer(BaseEstimator, TransformerMixin):
    """
    This class is made to work as a step in a sklearn.pipeline.Pipeline object.
    It imputes values in a pandas dataframe object based on the columns prefix.
    """
    def __init__(self, prefix: str, to_replace: Union[int, float, str],
                 replace_with: Union[int, float, str] = np.nan, ignore: list[str] = []) -> None:
        """
        prefix: prefix of the columns to be imputed.
        to_replace: value to be replaced.
        replace_with: value to replace "to_replace" with.
        ignore: list of columns to ignore.
        Initiates de class.
        """
        self.prefix = prefix
        self.to_replace = to_replace
        self.replace_with = replace_with
        self.ignore = ignore
        pass

    def fit(self, X: Union[pd.DataFrame, pd.Series], y: None = None) -> None:
        """
        X: dataset whose columns with "prefix" should be imputed.
        y: Shouldn't be used. Only exists to prevent raise Exception due to accidental input in a pipeline.
        Creates class atributte with the names of the columns to be imputed in the transform function.
        """
        self.prefix_cols = [
            col
            for col in X.columns
            if (
                    (col.startswith(self.prefix))
                    & (col not in self.ignore)
            )
        ]
        return self
    
    def transform(self, X: Union[pd.DataFrame, pd.Series]) -> Union[pd.DataFrame, pd.Series]:
        """
        X: dataset whose columns with "prefix" should be imputed.
        Returns dataset with the imputed columns.
        """
        X_ = X.copy()
        X_[self.prefix_cols] = X_[self.prefix_cols] \
            .replace(self.to_replace, self.replace_with)
        return X_
 
class AddNoneCount(BaseEstimator, TransformerMixin):
    """
    This class is made to work as a step in sklearn.pipeline.Pipeline object.
    It counts the number of None values in a pandas dataframe object based on the columns prefix.
    """
    def __init__(self, prefix: str = "", ignore: list[str] = []) -> None:
        """
        prefix: subset of variables for none count starting with this string.
        fake_value: values inserted to replace None.
        ignore: list of columns with prefix to ignore.
        drop_constant: whether to drop columns that would become constant without missing features or not.
        """
        self.prefix = prefix
        self.ignore = ignore
        pass

    def fit(self, X: pd.DataFrame, y: None = None) -> None:
        """
        X: dataset whose "prefix" variables' null values should be counted.
        y: Shouldn't be used. Only exists to prevent raise Exception due to accidental input in a pipeline.
        Creates class atributte with the names of the columns whose null values should be counted in the transform function.
        """
        self.prefix_cols = [
            col
            for col in X.columns
            if (
                    (col.startswith(self.prefix))
                    & (col not in self.ignore)
            )
        ]
        return self
    
    def transform(self, X: pd.DataFrame) -> pd.DataFrame:
        """
        X: dataset to apply transformation on.
        Returns dataset with new column with the count of the "prefix" variables' null values.
        """  
        X_ = X.copy()
        X_[f"none_count_{self.prefix}"] = X_[self.prefix_cols] \
            .isnull() \
            .sum(axis=1)
        return X_
    
class CustomEncoder(BaseEstimator, TransformerMixin):
    """
    This class is made to work as a step in sklearn.pipeline.Pipeline object.
    It encodes categorical variables in a pandas dataframe based on the categories mean of the target variable.
    Unknown values must be defined by the user.
    """
    def __init__(self, colname: str) -> None:
        """
        labels: dictionary with the labels to be replaced.
        colname: name of the column to be encoded.
        Initiates de class.
        """
        self.colname = colname
        pass

    def fit(self, X: pd.DataFrame, y: Union[pd.DataFrame, pd.Series]) -> None:
        """
        X: dataset whose column should be encoded.
        y: Shouldn't be used. Only exists to prevent raise Exception due to accidental input in a pipeline.
        Creates class atributte with the dictionary to be used in the transform function.
        """
        X_ = X.copy().assign(TARGET=y)

        grouped_X_ = X_ \
            .groupby(self.colname) \
            .agg({"TARGET": "mean"}) \
            .sort_values("TARGET", ascending=True)
        
        groups = grouped_X_.index

        self.labels ={
            groups[i]: i
            for i in range(len(groups))
        }

        self.most_frequent = X_[self.colname].mode()[0]
        return self
    
    def _apply_map(self, x: Union[int, str]) -> int:
        """
        x: value to be replaced.
        Returns the value to replace "x" with.
        """
        if x in self.labels.keys():
            return self.labels[x]
        else:
            return self.labels[self.most_frequent]
    
    def transform(self, X: pd.DataFrame) -> pd.DataFrame:
        """
        X: dataset whose column should be encoded.
        Returns dataset with the encoded column.
        """
        X_ = X.copy()
        X_[self.colname] = X_[self.colname] \
            .apply(self._apply_map)
        return X_
    
class CustomLog(BaseEstimator, TransformerMixin):
    def __init__(self, columns: list[str] = []) -> None:
        self.columns = columns
        pass

    def fit(self, X, y=None):
        return self

    def transform(self, X):
        X_ = X.copy()
        X_[self.columns] = np.log1p(
            X_[self.columns] - X_[self.columns].min()
        )
        return X_
Source: 00-support.ipynb

ID e TARGET

  • Entende-se ID como chave primária
  • Entende-se TARGET como variável de interesse
Mostrar/esconder código
nu = df["ID"].nunique() / df.shape[0]
print(f"Valores únicos em ID: {100*nu:.2f}%")
prop = df["TARGET"].value_counts(normalize=True).reset_index().set_index("TARGET")
print(f"TARGET = 0: {100*prop['proportion'][0]:.2f}%")
print(f"TARGET = 1: {100*prop['proportion'][1]:.2f}%")
Valores únicos em ID: 100.00%
TARGET = 0: 96.04%
TARGET = 1: 3.96%
Source: 01-eda.ipynb

Saldo

  • Variáveis numéricas
  • Dados esparsos
  • Variáveis criadas:
    1. Contagem de valores diferentes de 0 por cliente
    2. Soma das colunas por cliente
Mostrar/esconder código
saldo_cols = [
    col
    for col in df.columns
    if col.startswith("saldo")
]

df[saldo_cols].describe()
saldo_var1 saldo_var5 saldo_var8 saldo_var12 saldo_var13_corto saldo_var13_largo saldo_var13 saldo_var14 saldo_var17 saldo_var18 saldo_var20 saldo_var24 saldo_var26 saldo_var25 saldo_var29 saldo_var30 saldo_var31 saldo_var32 saldo_var33 saldo_var37 saldo_var40 saldo_var42 saldo_var44 saldo_medio_var5_hace2 saldo_medio_var5_hace3 saldo_medio_var5_ult1 saldo_medio_var5_ult3 saldo_medio_var8_hace2 saldo_medio_var8_hace3 saldo_medio_var8_ult1 saldo_medio_var8_ult3 saldo_medio_var12_hace2 saldo_medio_var12_hace3 saldo_medio_var12_ult1 saldo_medio_var12_ult3 saldo_medio_var13_corto_hace2 saldo_medio_var13_corto_hace3 saldo_medio_var13_corto_ult1 saldo_medio_var13_corto_ult3 saldo_medio_var13_largo_hace2 saldo_medio_var13_largo_hace3 saldo_medio_var13_largo_ult1 saldo_medio_var13_largo_ult3 saldo_medio_var17_hace2 saldo_medio_var17_hace3 saldo_medio_var17_ult1 saldo_medio_var17_ult3 saldo_medio_var29_hace2 saldo_medio_var29_hace3 saldo_medio_var29_ult1 saldo_medio_var29_ult3 saldo_medio_var33_hace2 saldo_medio_var33_hace3 saldo_medio_var33_ult1 saldo_medio_var33_ult3 saldo_medio_var44_hace2 saldo_medio_var44_hace3 saldo_medio_var44_ult1 saldo_medio_var44_ult3
count 5.701500e+04 57015.000000 57015.000000 5.701500e+04 57015.000000 5.701500e+04 5.701500e+04 57015.000000 5.701500e+04 5.701500e+04 57015.000000 5.701500e+04 57015.000000 57015.00000 57015.000000 5.701500e+04 5.701500e+04 57015.000000 57015.000000 57015.000000 57015.000000 5.701500e+04 57015.000000 57015.000000 5.701500e+04 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 5.701500e+04 57015.000000 5.701500e+04 5.701500e+04 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 5.701500e+04 5.701500e+04 5.701500e+04 5.701500e+04 5.701500e+04 5.701500e+04 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000
mean 5.647377e+01 1022.886994 148.382236 6.199680e+03 5043.312059 1.615135e+03 6.658447e+03 75.408646 2.280693e+02 5.261773e+01 30.574473 6.093697e+03 77.819255 74.31219 0.552634 1.402995e+04 3.477103e+02 3.507064 6.802496 38.415775 3.856036 7.371502e+03 112.838445 1579.223329 8.886114e+02 1087.701298 1060.380299 72.142799 9.915621 130.781521 114.667478 4.144313e+03 642.496189 5.888456e+03 4.520353e+03 3641.106408 563.469897 4905.352849 3893.801718 835.691014 175.699469 1.039760e+03 8.149678e+02 1.112751e+02 4.772609e+01 1.610807e+02 1.343335e+02 0.284095 0.002547 0.338542 0.248840 5.134473 0.970980 6.747690 4.802795 34.787668 1.683645 85.491811 64.562739
std 1.256438e+04 9585.151279 2674.921373 5.046542e+04 32941.531827 2.117787e+04 3.930945e+04 3135.619158 2.617611e+04 1.256396e+04 2836.440607 5.030050e+04 778.339337 764.19930 95.951729 6.551689e+04 2.685425e+04 133.813397 441.867908 512.919169 104.321349 5.136950e+04 5916.053871 12018.675179 1.048953e+04 9402.311934 8168.239694 1876.609553 494.372570 2286.641030 1951.417607 4.036733e+04 9786.216189 4.862178e+04 3.699663e+04 26377.374116 7323.317321 32191.041402 25734.410218 13702.722377 4857.309719 1.704264e+04 1.313540e+04 1.775392e+04 9.943442e+03 1.723843e+04 1.508243e+04 48.289987 0.608096 60.135284 36.811052 352.560232 83.872097 439.445853 300.110789 2260.794741 143.601303 4518.340854 3202.202490
min 0.000000e+00 -1842.000000 -4942.260000 0.000000e+00 0.000000 0.000000e+00 0.000000e+00 0.000000 0.000000e+00 0.000000e+00 0.000000 0.000000e+00 0.000000 0.00000 0.000000 -4.942260e+03 0.000000e+00 0.000000 0.000000 0.000000 0.000000 -4.942260e+03 0.000000 -128.370000 -8.040000e+00 -922.380000 -476.070000 -287.670000 0.000000 -3401.340000 -1844.520000 0.000000e+00 0.000000 0.000000e+00 0.000000e+00 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000e+00 0.000000e+00 -3.000000e-02 0.000000e+00 0.000000e+00 0.000000e+00 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000
25% 0.000000e+00 0.000000 0.000000 0.000000e+00 0.000000 0.000000e+00 0.000000e+00 0.000000 0.000000e+00 0.000000e+00 0.000000 0.000000e+00 0.000000 0.00000 0.000000 0.000000e+00 0.000000e+00 0.000000 0.000000 0.000000 0.000000 0.000000e+00 0.000000 0.000000 0.000000e+00 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000e+00 0.000000 0.000000e+00 0.000000e+00 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000
50% 0.000000e+00 3.000000 0.000000 0.000000e+00 0.000000 0.000000e+00 0.000000e+00 0.000000 0.000000e+00 0.000000e+00 0.000000 0.000000e+00 0.000000 0.00000 0.000000 3.000000e+00 0.000000e+00 0.000000 0.000000 0.000000 0.000000 3.000000e+00 0.000000 3.000000 9.900000e-01 3.000000 2.730000 0.000000 0.000000 0.000000 0.000000 0.000000e+00 0.000000 0.000000e+00 0.000000e+00 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000
75% 0.000000e+00 90.000000 0.000000 0.000000e+00 0.000000 0.000000e+00 0.000000e+00 0.000000 0.000000e+00 0.000000e+00 0.000000 0.000000e+00 0.000000 0.00000 0.000000 2.429700e+02 0.000000e+00 0.000000 0.000000 0.000000 0.000000 1.200000e+02 0.000000 90.000000 1.245000e+01 90.000000 83.820000 0.000000 0.000000 0.000000 0.000000 0.000000e+00 0.000000 0.000000e+00 0.000000e+00 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000
max 3.000000e+06 619329.150000 240045.000000 3.008077e+06 450000.000000 1.500000e+06 1.500000e+06 450000.000000 6.119500e+06 3.000000e+06 455858.160000 3.008077e+06 69756.720000 69756.72000 19531.800000 3.458077e+06 6.119500e+06 12210.780000 46005.300000 60000.000000 8192.610000 3.008077e+06 740006.610000 812137.260000 1.542339e+06 601428.600000 544365.570000 231351.990000 73362.240000 228031.800000 177582.000000 3.000538e+06 668335.320000 3.004186e+06 2.272859e+06 450000.000000 304838.700000 450000.000000 450000.000000 840000.000000 534000.000000 1.500000e+06 1.034483e+06 4.210084e+06 2.368559e+06 3.998687e+06 3.525777e+06 10430.010000 145.200000 13793.670000 7331.340000 43406.220000 11473.050000 46190.910000 32689.440000 438329.220000 24650.010000 681462.900000 397884.300000
Source: 01-eda.ipynb

Saldo: contagem de não-zeros

Clientes sem nenhum tipo de saldo parecem mais insatisfeitos.

Mostrar/esconder código
anzc_saldo = AddNonZeroCount(prefix="saldo")
anzc_saldo = anzc_saldo.fit(df)
df = anzc_saldo.transform(df)
col = "non_zero_count_saldo"
cv.binaryhistplot(x=df[col], hue=df["TARGET"], nbins=15)

Source: 01-eda.ipynb

Saldo: soma total

Pouco poder de discriminação.

Mostrar/esconder código
cs_saldo = CustomSum(prefix="saldo")
cs_saldo = cs_saldo.fit(df)
df = cs_saldo.transform(df)
col = "sum_of_saldo"
min_sum = df[col].min()
cv.binaryhistplot(
    x=df[col].apply(lambda x: np.log(x+1-min_sum)),
    hue=df["TARGET"],
    nbins=10
)

Source: 01-eda.ipynb

Imp

Mesmas observações que saldo.

Mostrar/esconder código
imp_cols = [
    col
    for col in df.columns
    if col.startswith("imp")
]

df[imp_cols].describe()
imp_ent_var16_ult1 imp_op_var39_comer_ult1 imp_op_var39_comer_ult3 imp_op_var40_comer_ult1 imp_op_var40_comer_ult3 imp_op_var40_efect_ult1 imp_op_var40_efect_ult3 imp_op_var40_ult1 imp_op_var41_comer_ult1 imp_op_var41_comer_ult3 imp_op_var41_efect_ult1 imp_op_var41_efect_ult3 imp_op_var41_ult1 imp_op_var39_efect_ult1 imp_op_var39_efect_ult3 imp_op_var39_ult1 imp_sal_var16_ult1 imp_amort_var18_ult1 imp_aport_var13_hace3 imp_aport_var13_ult1 imp_aport_var17_hace3 imp_aport_var17_ult1 imp_aport_var33_hace3 imp_aport_var33_ult1 imp_var7_emit_ult1 imp_var7_recib_ult1 imp_compra_var44_hace3 imp_compra_var44_ult1 imp_reemb_var13_ult1 imp_reemb_var17_hace3 imp_reemb_var17_ult1 imp_var43_emit_ult1 imp_trans_var37_ult1 imp_trasp_var17_in_hace3 imp_trasp_var17_in_ult1 imp_trasp_var17_out_ult1 imp_trasp_var33_in_hace3 imp_trasp_var33_in_ult1 imp_trasp_var33_out_ult1 imp_venta_var44_hace3 imp_venta_var44_ult1
count 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 5.701500e+04 57015.000000 57015.000000 57015.000000 57015.000000 5.701500e+04 57015.000000 5.701500e+04 57015.000000 57015.000000 57015.000000 57015.000000 5.701500e+04 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 5.701500e+04
mean 81.003089 71.913524 118.994372 3.083158 5.752563 0.365381 0.535336 2.829000 68.830367 113.241809 67.552170 111.873221 136.944608 67.917551 112.408557 139.773609 4.233566 0.275222 2873.495172 635.007707 1.277614e+02 36.573989 2.255196 0.036832 3.628605 1.452222e+02 12.487925 1.506857e+02 48.314042 0.210947 13.714707 868.027511 1.931449e+03 2.499744 3.350238 2.495498 1.948178 0.184902 0.052618 1.369179 1.050151e+02
std 1366.949303 335.251692 543.966435 80.869729 140.495976 32.236893 38.987633 87.985696 318.924638 515.535254 531.479065 863.633698 703.052574 535.168344 867.890588 717.360016 294.988427 65.717071 25674.815161 11484.052874 2.553634e+04 2754.144279 202.168560 4.352167 639.990954 6.784845e+03 966.472697 1.572070e+04 3063.934595 50.369561 1206.186327 13840.787878 2.332046e+04 448.305976 587.705484 412.478795 271.121054 27.264123 12.563964 326.833072 1.301850e+04
min 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000e+00 0.000000 0.000000 0.000000 0.000000 0.000000e+00 0.000000 0.000000e+00 0.000000 0.000000 0.000000 0.000000 0.000000e+00 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000e+00
25% 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000e+00 0.000000 0.000000 0.000000 0.000000 0.000000e+00 0.000000 0.000000e+00 0.000000 0.000000 0.000000 0.000000 0.000000e+00 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000e+00
50% 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000e+00 0.000000 0.000000 0.000000 0.000000 0.000000e+00 0.000000 0.000000e+00 0.000000 0.000000 0.000000 0.000000 0.000000e+00 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000e+00
75% 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000e+00 0.000000 0.000000 0.000000 0.000000 0.000000e+00 0.000000 0.000000e+00 0.000000 0.000000 0.000000 0.000000 0.000000e+00 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000e+00
max 135000.000000 9968.040000 16086.060000 8237.820000 10351.950000 6600.000000 6600.000000 8237.820000 9177.180000 16086.060000 45990.000000 96870.000000 47598.090000 45990.000000 96870.000000 47598.090000 58048.050000 15691.800000 840000.000000 450000.000000 6.083692e+06 432457.320000 36000.000000 750.000000 145384.920000 1.039260e+06 128874.510000 3.410059e+06 450000.000000 12027.150000 182132.970000 901169.100000 1.608000e+06 96781.440000 133730.580000 69622.290000 44251.080000 5400.000000 3000.000000 78040.590000 2.754476e+06
Source: 01-eda.ipynb

Imp: contagem de não-zeros

Pouco poder de discriminação.

Mostrar/esconder código
anzc_imp = AddNonZeroCount(prefix="imp")
anzc_imp = anzc_imp.fit(df)
df = anzc_imp.transform(df)
col = "non_zero_count_imp"
cv.binaryhistplot(df[col], df["TARGET"], nbins=10)

Source: 01-eda.ipynb

Imp: soma total

Pouco poder de discriminação.

Mostrar/esconder código
cs_imp = CustomSum(prefix="imp")
cs_imp = cs_imp.fit(df)
df = cs_imp.transform(df)
col = "sum_of_imp"
cv.binaryhistplot(df["sum_of_imp"].apply(lambda x: np.log(x + 1)), df["TARGET"], nbins=15)

Source: 01-eda.ipynb

Delta

  • Valores faltantes
  • Variáveis numéricas
  • Dados ainda mais esparsos que saldo e imp
  • Variáveis criadas:
    1. Contagem de valores nulos por cliente
    2. Contagem de valores diferentes de 0 por cliente
    3. Soma das colunas por cliente
Mostrar/esconder código
ci_delta = CustomImputer(prefix="delta", to_replace=9999999999)
ci_delta = ci_delta.fit(df)
df = ci_delta.transform(df)
df[delta_cols].describe()
delta_imp_amort_var18_1y3 delta_imp_aport_var13_1y3 delta_imp_aport_var17_1y3 delta_imp_aport_var33_1y3 delta_imp_compra_var44_1y3 delta_imp_reemb_var13_1y3 delta_imp_reemb_var17_1y3 delta_imp_trasp_var17_in_1y3 delta_imp_trasp_var17_out_1y3 delta_imp_trasp_var33_in_1y3 delta_imp_trasp_var33_out_1y3 delta_imp_venta_var44_1y3 delta_num_aport_var13_1y3 delta_num_aport_var17_1y3 delta_num_aport_var33_1y3 delta_num_compra_var44_1y3 delta_num_venta_var44_1y3
count 57014.0 56740.000000 56981.000000 57014.000000 56958.000000 56986.0 57002.000000 57011.000000 57012.0 57012.000000 57014.0 56979.000000 56740.000000 56981.000000 57014.000000 56958.000000 56979.000000
mean 0.0 -0.022012 -0.000219 -0.000247 0.000009 0.0 -0.000018 -0.000035 0.0 -0.000053 0.0 0.000078 -0.022259 -0.000246 -0.000202 0.000050 0.000123
std 0.0 0.152221 0.016886 0.015401 0.036869 0.0 0.004188 0.005923 0.0 0.007254 0.0 0.023077 0.147766 0.015673 0.014046 0.030126 0.033775
min 0.0 -1.000000 -1.000000 -1.000000 -1.000000 0.0 -1.000000 -1.000000 0.0 -1.000000 0.0 -1.000000 -1.000000 -1.000000 -1.000000 -1.000000 -1.000000
25% 0.0 0.000000 0.000000 0.000000 0.000000 0.0 0.000000 0.000000 0.0 0.000000 0.0 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000
50% 0.0 0.000000 0.000000 0.000000 0.000000 0.0 0.000000 0.000000 0.0 0.000000 0.0 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000
75% 0.0 0.000000 0.000000 0.000000 0.000000 0.0 0.000000 0.000000 0.0 0.000000 0.0 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000
max 0.0 5.500000 1.500000 0.000000 6.267608 0.0 0.000000 0.000000 0.0 0.000000 0.0 5.417098 1.000000 0.000000 0.000000 4.000000 8.000000
Source: 01-eda.ipynb

Uma discussão mais profunda sobre o tema de valores faltantes e preprocessamento pode ser encontrada no notebook de origem do código acima.

Delta: valores faltantes

Correlação na ocorrência mostra relação entre variáveis com nomes semelhantes (oportunidade de melhorias no projeto).

Mostrar/esconder código
# Biblioteca missingno, disponível no PyPi
mno.matrix(df[delta_cols])
plt.show()
mno.dendrogram(df[delta_cols])
plt.show()

Source: 01-eda.ipynb

Delta: contagem de valores faltantes

Pouco poder de discriminação.

Mostrar/esconder código
anc_delta = AddNoneCount(prefix="delta")
anc_delta = anc_delta.fit(df)
df = anc_delta.transform(df)
col = "none_count_delta"
cv.binaryhistplot(df[col], df["TARGET"], nbins=5)

Source: 01-eda.ipynb

Delta: contagem de não-zeros

Pouco poder de discriminação.

Mostrar/esconder código
anzc_delta = AddNonZeroCount(prefix="delta")
anzc_delta = anzc_delta.fit(df)
df = anzc_delta.transform(df)
col = "non_zero_count_delta"
cv.binaryhistplot(df[col], df["TARGET"], nbins=7)

Source: 01-eda.ipynb

Delta: soma total

Pouco poder de discriminação.

Mostrar/esconder código
cs_delta = CustomSum(prefix="delta")
cs_delta = cs_delta.fit(df)
df = cs_delta.transform(df)
col = "sum_of_delta"
cv.binaryhistplot(df[col], df["TARGET"], nbins=8)

Source: 01-eda.ipynb

Ind

  • Variáveis binárias
  • Variável criada:
    • Contagem de valores diferentes de 0 por cliente
Mostrar/esconder código
ind_cols = [
    col
    for col in df.columns
    if col.startswith("ind")
]
df[ind_cols].describe().loc[["min", "max"]]
ind_var1_0 ind_var1 ind_var5_0 ind_var5 ind_var8_0 ind_var8 ind_var12_0 ind_var12 ind_var13_0 ind_var13_corto_0 ind_var13_corto ind_var13_largo_0 ind_var13_largo ind_var13 ind_var14_0 ind_var14 ind_var17_0 ind_var17 ind_var18 ind_var19 ind_var20_0 ind_var20 ind_var24_0 ind_var24 ind_var25_cte ind_var26_cte ind_var26 ind_var25 ind_var29_0 ind_var29 ind_var30_0 ind_var30 ind_var31_0 ind_var31 ind_var32_cte ind_var32 ind_var33_0 ind_var33 ind_var37_cte ind_var37 ind_var39_0 ind_var40_0 ind_var41_0 ind_var39 ind_var44_0 ind_var44 ind_var7_emit_ult1 ind_var7_recib_ult1 ind_var10_ult1 ind_var10cte_ult1 ind_var9_cte_ult1 ind_var9_ult1 ind_var43_emit_ult1 ind_var43_recib_ult1
min 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
max 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0
Source: 01-eda.ipynb

Ind: contagem de valores diferentes de 0

Distribuições parecidas, mas com centros ligeiramente distantes. Não foram realizados testes de hipótese para validar a significância estatística deste (e de outros) deslocamento(s), o que poderia ser um ponto de melhoria no estudo.

Mostrar/esconder código
anzc_ind = AddNonZeroCount(prefix="ind")
anzc_ind = anzc_ind.fit(df)
df = anzc_ind.transform(df)
col = "non_zero_count_ind"
cv.binaryhistplot(df[col], df["TARGET"], nbins=15)

Source: 01-eda.ipynb

Num

  • Variáveis numéricas discretas
  • Variáveis criadas:
    1. Contagem de valores diferentes de 0 por cliente
    2. Soma das colunas por cliente
Mostrar/esconder código
num_cols = [
    col
    for col in df.columns
    if col.startswith("num")
]
df[num_cols].describe()
num_var1_0 num_var1 num_var4 num_var5_0 num_var5 num_var8_0 num_var8 num_var12_0 num_var12 num_var13_0 num_var13_corto_0 num_var13_corto num_var13_largo_0 num_var13_largo num_var13 num_var14_0 num_var14 num_var17_0 num_var17 num_var18 num_var20_0 num_var20 num_var24_0 num_var24 num_var26 num_var25 num_op_var40_hace2 num_op_var40_hace3 num_op_var40_ult1 num_op_var40_ult3 num_op_var41_hace2 num_op_var41_hace3 num_op_var41_ult1 num_op_var41_ult3 num_op_var39_hace2 num_op_var39_hace3 num_op_var39_ult1 num_op_var39_ult3 num_var29_0 num_var29 num_var30_0 num_var30 num_var31_0 num_var31 num_var32 num_var33_0 num_var33 num_var35 num_var37_med_ult2 num_var37 num_var39_0 num_var40_0 num_var41_0 num_var39 num_var42_0 num_var42 num_var44_0 num_var44 num_aport_var13_hace3 num_aport_var13_ult1 num_aport_var17_hace3 num_aport_var17_ult1 num_aport_var33_hace3 num_aport_var33_ult1 num_var7_emit_ult1 num_var7_recib_ult1 num_compra_var44_hace3 num_compra_var44_ult1 num_ent_var16_ult1 num_var22_hace2 num_var22_hace3 num_var22_ult1 num_var22_ult3 num_med_var22_ult3 num_med_var45_ult3 num_meses_var5_ult3 num_meses_var8_ult3 num_meses_var12_ult3 num_meses_var13_corto_ult3 num_meses_var13_largo_ult3 num_meses_var17_ult3 num_meses_var29_ult3 num_meses_var33_ult3 num_meses_var39_vig_ult3 num_meses_var44_ult3 num_op_var39_comer_ult1 num_op_var39_comer_ult3 num_op_var40_comer_ult1 num_op_var40_comer_ult3 num_op_var40_efect_ult1 num_op_var40_efect_ult3 num_op_var41_comer_ult1 num_op_var41_comer_ult3 num_op_var41_efect_ult1 num_op_var41_efect_ult3 num_op_var39_efect_ult1 num_op_var39_efect_ult3 num_reemb_var13_ult1 num_reemb_var17_hace3 num_reemb_var17_ult1 num_sal_var16_ult1 num_var43_emit_ult1 num_var43_recib_ult1 num_trasp_var11_ult1 num_trasp_var17_in_hace3 num_trasp_var17_in_ult1 num_trasp_var17_out_ult1 num_trasp_var33_in_hace3 num_trasp_var33_in_ult1 num_trasp_var33_out_ult1 num_venta_var44_hace3 num_venta_var44_ult1 num_var45_hace2 num_var45_hace3 num_var45_ult1 num_var45_ult3
count 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.00000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.00000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.00000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000 57015.000000
mean 0.033517 0.010524 1.081312 2.894712 1.999105 0.098763 0.085977 0.212839 0.139069 0.168956 0.129966 0.124283 0.038990 0.036885 0.161168 0.073402 0.016259 0.012681 0.009471 0.000053 0.010366 0.007524 0.129071 0.115285 0.089976 0.085977 0.021415 0.001210 0.054828 0.077453 1.619521 0.092555 2.867929 4.580005 1.640937 0.093765 2.922757 4.657459 0.000421 0.000105 3.375691 2.385425 0.020784 0.016417 0.003999 0.00221 0.001684 3.304499 0.268719 0.425046 2.724809 0.033465 2.699658 0.010471 3.206735 2.219258 0.005893 0.005262 0.077295 0.017890 0.001421 0.003631 0.001000 0.000316 0.000158 0.010629 0.001736 0.008945 0.182426 1.307656 1.191213 0.567535 3.066404 0.642620 4.029361 1.979374 0.053688 0.102885 0.098676 0.018381 0.002947 0.00014 0.001245 1.592809 0.003560 2.195633 3.611313 0.068403 0.129071 0.002105 0.003368 2.12723 3.482242 0.715022 1.206262 0.717127 1.209629 0.001526 0.000053 0.001052 0.005157 0.394002 0.815259 0.120442 0.000158 0.000210 0.000158 0.000158 0.000210 0.000053 0.000105 0.005525 5.412155 3.887240 4.371902 13.671297
std 0.315827 0.177371 0.912384 0.655596 1.431735 0.535590 0.500544 0.921900 0.640704 0.746919 0.617687 0.598101 0.409706 0.386513 0.714767 0.654402 0.225918 0.388061 0.302956 0.012564 0.176040 0.150056 0.612357 0.577511 0.624008 0.609736 0.975323 0.205295 1.901246 2.729616 7.435993 1.195419 11.003501 17.084495 7.511256 1.212827 11.226281 17.374287 0.035534 0.017768 1.357028 1.644851 0.429071 0.341847 0.128067 0.09566 0.077432 2.876147 1.648699 2.247020 1.137049 0.315582 1.103534 0.176929 0.973878 1.499418 0.134018 0.125531 0.562886 0.297313 0.098118 0.177201 0.072168 0.035535 0.021761 0.230743 0.096491 0.356584 0.989789 3.482298 3.269985 2.136909 6.260567 1.853569 10.853226 1.298381 0.334850 0.489703 0.485321 0.219571 0.077167 0.01567 0.056950 0.718959 0.086671 9.218574 15.030522 2.092828 4.201767 0.138759 0.204886 8.89960 14.306734 3.210513 5.179312 3.220403 5.198728 0.067642 0.012564 0.100507 0.160816 2.182773 3.415429 1.209621 0.028094 0.025127 0.021761 0.021761 0.030775 0.012564 0.017768 0.303314 14.494831 10.326638 14.180760 33.076786
min 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.00000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.00000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.00000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000
25% 0.000000 0.000000 0.000000 3.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 3.000000 0.000000 0.000000 0.000000 0.000000 0.00000 0.000000 0.000000 0.000000 0.000000 3.000000 0.000000 3.000000 0.000000 3.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.00000 0.000000 1.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.00000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000
50% 0.000000 0.000000 1.000000 3.000000 3.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 3.000000 3.000000 0.000000 0.000000 0.000000 0.00000 0.000000 3.000000 0.000000 0.000000 3.000000 0.000000 3.000000 0.000000 3.000000 3.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 3.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.00000 0.000000 2.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.00000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000
75% 0.000000 0.000000 1.000000 3.000000 3.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 3.000000 3.000000 0.000000 0.000000 0.000000 0.00000 0.000000 3.000000 0.000000 0.000000 3.000000 0.000000 3.000000 0.000000 3.000000 3.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 3.000000 0.000000 3.000000 3.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.00000 0.000000 2.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.00000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 3.000000 3.000000 3.000000 12.000000
max 6.000000 3.000000 7.000000 15.000000 15.000000 6.000000 3.000000 111.000000 15.000000 15.000000 6.000000 6.000000 15.000000 15.000000 15.000000 111.000000 12.000000 36.000000 27.000000 3.000000 3.000000 3.000000 9.000000 6.000000 33.000000 33.000000 117.000000 48.000000 234.000000 351.000000 249.000000 81.000000 468.000000 468.000000 249.000000 81.000000 468.000000 468.000000 3.000000 3.000000 114.000000 33.000000 36.000000 27.000000 6.000000 12.00000 6.000000 36.000000 90.000000 111.000000 33.000000 6.000000 33.000000 3.000000 114.000000 18.000000 6.000000 3.000000 24.000000 30.000000 12.000000 21.000000 12.000000 6.000000 3.000000 24.000000 9.000000 39.000000 60.000000 123.000000 108.000000 96.000000 234.000000 78.000000 267.000000 3.000000 3.000000 3.000000 3.000000 3.000000 3.000000 2.00000 3.000000 3.000000 3.000000 438.000000 600.000000 210.000000 582.000000 24.000000 24.000000 438.00000 438.000000 90.000000 156.000000 90.000000 156.000000 3.000000 3.000000 21.000000 15.000000 102.000000 159.000000 93.000000 6.000000 3.000000 3.000000 3.000000 6.000000 3.000000 3.000000 39.000000 342.000000 333.000000 510.000000 801.000000
Source: 01-eda.ipynb

Num: contagem de não-zeros

Aparenta ter bom poder de discriminação.

Mostrar/esconder código
anzc_num = AddNonZeroCount(prefix="num")
anzc_num = anzc_num.fit(df)
df = anzc_num.transform(df)
col = "non_zero_count_num"
cv.binaryhistplot(
    df[col].apply(np.log1p),
    df["TARGET"],
    nbins=15
)

Source: 01-eda.ipynb

Num: soma total

Também aparenta ter bom poder de discriminação.

Mostrar/esconder código
cs_num = CustomSum(prefix="num")
cs_num = cs_num.fit(df)
df = cs_num.transform(df)
col = "sum_of_num"
cv.binaryhistplot(
    df[col].apply(np.log1p),
    df["TARGET"],
    nbins=16
)

Source: 01-eda.ipynb

Variáveis categóricas

  • var36 e var21
  • Transformações:
    • Target Encoder customizado para modelos de árvore:
      • Ordenação por média da variável alvo
      • Classes desconhecidas entram na categoria mais frequente
    • One Hot Encoding:
      • Classes pouco frequentes (n < 100) agrupadas em “outros”
      • Classes desconhecidas entram na categoria mais frequente

var36

  • Proporções diferentes entre classes (inclusive frequentes)
  • Pode agregar poder de discriminação
Mostrar/esconder código
CE_var36 = CustomEncoder(colname="var36")
CE_var36 = CE_var36.fit(df.drop("TARGET", axis=1), df["TARGET"])
df = CE_var36.transform(df)
ordered_df = df \
    .groupby(var)["TARGET"] \
    .value_counts(normalize=True) \
    .unstack() \
    .sort_values(by=0, ascending=False) \
    .merge(
        df[var] \
            .value_counts() \
            .reset_index() \
            .set_index(var),
        left_index=True,
        right_index=True
    )
ordered_df
0 1 count
var36
0 1.000000 NaN 302
1 0.983975 0.016025 16599
2 0.970058 0.029942 6546
3 0.968026 0.031974 11009
4 0.936079 0.063921 22559
Source: 01-eda.ipynb

var21

Classes pouco frequentes: abordagem diferente para cada algorítmo. Nenhuma classe de proporção de insatisfeitos maior que a da classe mais frequente é populosa o suficiente nem tem proporção de insatisfeitos tão maior que a da classe mais frequente para ser considerada relevante.

Mostrar/esconder código
CE_var21 = CustomEncoder(colname="var21")
CE_var21 = CE_var21.fit(df.drop("TARGET", axis=1), df["TARGET"])
df = CE_var21.transform(df)
ordered_df = df \
    .groupby(var)["TARGET"] \
    .value_counts(normalize=True) \
    .unstack() \
    .merge(
        df[var] \
            .value_counts() \
            .reset_index() \
            .set_index(var),
        left_index=True,
        right_index=True
    )
ordered_df
0 1 count
var21
0 1.000000 NaN 1
1 1.000000 NaN 9
2 1.000000 NaN 8
3 1.000000 NaN 20
4 1.000000 NaN 20
5 1.000000 NaN 3
6 1.000000 NaN 23
7 1.000000 NaN 1
8 1.000000 NaN 2
9 1.000000 NaN 2
10 1.000000 NaN 2
11 1.000000 NaN 1
12 0.985915 0.014085 71
13 0.977273 0.022727 44
14 0.976744 0.023256 43
15 0.960465 0.039535 56380
16 0.954545 0.045455 66
17 0.940120 0.059880 167
18 0.939189 0.060811 148
19 0.500000 0.500000 2
20 0.500000 0.500000 2
Source: 01-eda.ipynb

Demais variáveis

  • var3, var15, var38

var3

Outra variável que possui valores faltantes (-999999) e que também não parece ter poder de discriminação. Como lidar com estes valores varia entre o algorítmo implementado, como discutido anteriormente e no notebook de origem do código abaixo (seção de delta).

Mostrar/esconder código
col = "var3"
ci_var3 = CustomImputer(prefix="var3", to_replace=-999999)
ci_var3 = ci_var3.fit(df)
df = ci_var3.transform(df)
df \
    .assign(null = df[col].isna().astype(int)) \
    .groupby("null")["TARGET"] \
    .value_counts(normalize=True) \
    .unstack()
TARGET 0 1
null
0 0.960406 0.039594
1 0.977011 0.022989
Source: 01-eda.ipynb

Pela distribuição, aparenta ser uma variável numérica discreta com forte assimetria à direita.

Mostrar/esconder código
col = "var3"
cv.binaryhistplot(df[col], df["TARGET"], nbins=50)

Source: 01-eda.ipynb

var15

Esta variável parece contínua mas tem apenas valores inteiros. Sua distribuição é bem diferente entre as classes da variável de interesse, podendo ser uma boa preditora.

Mostrar/esconder código
col = "var15"
cv.binaryhistplot(df[col], df["TARGET"], nbins=20)

Source: 01-eda.ipynb

var38

Variável numérica com forte assimetria à direita. Seu poder de predição não parece tão forte quanto o de var15, mas pode ser muito útil na análise de clusters por ter boa variabilidade.

Mostrar/esconder código
var = "var38"
cv.binaryhistplot(
    df[var].apply(np.log1p),
    df["TARGET"],
    nbins=20
)

Source: 01-eda.ipynb

2 - Classificação e Rankeamento

Wrapper de pipelines

Uma função recebe o conjunto de treino, um pipeline de classificação e uma malha de hiperparâmetros, executa a sequência de fit do wrapper para então serializar e armazenar a instância da classe com pickle para uso posterior.

  • Sequência de fit:
    1. Separa o conjunto de treino entre treino e validação
    2. Treina o pipeline com GridSearchCV no conjunto de treino maximizando a AUC
    3. Ajusta o corte de classificação no conjunto de validação maximizando o lucro total (com os valores propostos no case)
    4. Retreina o modelo com os melhores hiperparâmetros no conjunto de treino completo
  • Avaliação:
    1. Já treinado, recebe o conjunto de teste
    2. Classifica o conjunto de teste
    3. Calcula métricas de negócios
    4. Calcula métricas de classificação
  • Feature Importances
    1. Ainda treinado, recebe o conjunto de teste
    2. Classifica o conjunto de teste fazendo shuffle em cada variável de interesse diversas vezes
    3. Para cada variável, calcula a diferença média na métrica de lucro total entre o conjunto original e o conjunto com a variável embaralhada
    4. Ordena as variáveis pela diferença média e retorna o resultado em um DataFrame
  • Rankeamento:
    1. Ainda treinado, recebe o conjunto de teste
    2. Classifica o conjunto de testes usando quocientes do corte de classificação
Mostrar/esconder código
# Este código é apenas uma reprodução do script original.
# --- Function Building --- #
from typing import Union

# --- Threshold Optimization --- #
from scipy.optimize import minimize_scalar

# --- Data Manipulation --- #
import numpy as np
import pandas as pd

# --- sklearn utils --- #
from sklearn.pipeline import Pipeline
from sklearn.model_selection import \
    train_test_split, \
    GridSearchCV, \
    StratifiedKFold

# --- sklearn metrics --- #
from sklearn.inspection import permutation_importance
from sklearn.metrics import \
    roc_auc_score, \
    confusion_matrix, \
    accuracy_score, \
    precision_score, \
    recall_score, \
    f1_score

# --- Object serialization --- #
import pickle

class TrainEvaluate:
    """
    This class can be used to train, validate and test sklearn Pipeline objects.
    """
    def __init__(self, model: Pipeline, param_grid: dict, target: str,
                 njobs: int = 8, verbose: bool = True) -> None:
        """
        model: sklearn Pipeline with the model.
        param_grid: Dictionary of parameters to search over.
        target: Name of the column to predict.
        save_model: Wheter to save the model or not.
        save_name: Name of the file to save the model.
        njobs: Number of jobs to run in parallel.
        verbose: Wheter to print the progress or not.
        Initialize the class with the model, param_grid, and target variable.
        """
        self.model = model
        self.param_grid = param_grid
        self.target = target
        self.njobs = njobs
        self.verbose = verbose
        pass

    def _validation_split(self, df: pd.DataFrame) -> tuple:
        """
        df: Pandas DataFrame with the data.
        Split the data into train and validation sets.
        """
        y = df[self.target]
        X = df.drop(self.target, axis=1)
        X_train, X_val, y_train, y_val = train_test_split(
            X,
            y,
            test_size=0.25,
            random_state=42
        )
        return (X_train, X_val, y_train, y_val)
    
    def _grid_search(self, X_train: pd.DataFrame, y_train: Union[pd.DataFrame, pd.Series]) -> GridSearchCV:
        """
        X_train: Pandas DataFrame with the training data.
        y_train: Pandas Series with the training target.
        """
        skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
        grid_search = GridSearchCV(
            estimator=self.model,
            param_grid=self.param_grid,
            scoring="roc_auc",
            n_jobs=self.njobs,
            cv=skf,
            verbose=3
        )
        grid_search = grid_search.fit(X_train, y_train)
        return grid_search
    
    def _profit(self, y_true: Union[np.ndarray, pd.DataFrame, pd.Series],
                y_pred: Union[np.ndarray, pd.DataFrame, pd.Series]) -> float:
        """
        y_true: Pandas Series with the true target.
        y_pred: Pandas Series with the predicted target.
        Calculate the profit metric of the model.
        """
        tp = np.sum((y_pred == 1) & (y_true == 1))
        fp = np.sum((y_pred == 1) & (y_true == 0))
        n = len(y_true)
        profit = (90 * tp - 10 * fp)
        return profit
    
    def _threshold_tuning(self, X_val: pd.DataFrame, y_val: Union[pd.DataFrame, pd.Series]) -> float:
        """
        X_val: Pandas DataFrame with the validation data.
        y_val: Pandas Series with the validation target.
        Find the threshold that maximizes the profit metric.
        """
        y_proba = self.best_model_.predict_proba(X_val)[:, 1]

        def profit_treshold(x: float) -> float:
            """
            x: Threshold to test.
            Returns negative of the profit metric.
            """
            y_pred = (y_proba >= x).astype(int)
            scalar = -self._profit(y_val, y_pred)
            return scalar
        
        threshold = minimize_scalar(
            profit_treshold,
            bounds=(0, 1),
            method="bounded"
        )
        self.threshold = threshold.x
        return threshold.x
        
    def fit(self, df: pd.DataFrame) -> None:
        """
        df: Pandas DataFrame with the data.
        path: Path to a fitted model.
        Splits data between train and validation, performs GridSearchCV,
        adjusts the threshold based on profit metric on the validation set,
        and fits the model on the original data.
        """
        if self.verbose:
            print("Splitting data into train and validation sets...")
        X_train, X_val, y_train, y_val = self._validation_split(df)
        if self.verbose:
            print("Done!")
            print("Performing GridSearchCV...")
        self.best_model_ = self._grid_search(X_train, y_train).best_estimator_
        if self.verbose:
            print("Done!")
            print("Adjusting threshold based on validation set...")
        self.threshold = self._threshold_tuning(X_val, y_val)
        if self.verbose:
            print("Done!")
            print("Fitting model on the whole dataset...")
        self.best_model_ = self.best_model_.fit(df.drop(self.target, axis=1), df[self.target])
        if self.verbose:
            print("Done!")

        return self
    
    def predict_proba(self, df: pd.DataFrame) -> np.ndarray:
        """
        df: Pandas DataFrame with the data.
        Predicts the target variable using the best model.
        """
        return self.best_model_.predict_proba(df)[:, 1]
    
    def predict(self, df: pd.DataFrame) -> np.ndarray:
        """
        df: Pandas DataFrame with the data.
        Predicts the target variable using the best model and the threshold.
        """
        y_proba = self.predict_proba(df)
        y_pred = (y_proba >= self.threshold).astype(int)
        return y_pred
    
    def evaluate(self, df: pd.DataFrame) -> dict:
        """
        df: Pandas DataFrame with the test data.
        Evaluates the model on the data.
        """
        X_test = df.drop(self.target, axis=1)
        y_true = df[self.target]
        y_proba = self.predict_proba(X_test)
        y_pred = self.predict(X_test)

        tn, fp, fn, tp = confusion_matrix(y_true, y_pred).ravel()

        self.business_metrics = {
            "Profit (Total)": tp * 90 - fp * 10,
            "Profit (per Customer)": (tp * 90 - fp * 10) / len(y_true),
            "True Positive Profit (Total)": tp * 90,
            "True Positive Profit (per Customer)": tp * 90 / len(y_true),
            "False Positive Loss (Total)": fp * 10,
            "False Positive Loss (per Customer)": fp * 10 / len(y_true),
            "False Negative Potential Profit Loss (Total)": fn * 90,
            "False Negative Potential Profit Loss (per Customer)": fn * 90 / len(y_true),
            "True Negative Loss Prevention (Total)": tn * 10,
            "True Negative Loss Prevention (per Customer)": tn * 10 / len(y_true)
        }

        self.classification_metrics = {
            "Classification Threshold": self.threshold,
            "ROC AUC": roc_auc_score(y_true, y_proba),
            "Precision": precision_score(y_true, y_pred),
            "Recall": recall_score(y_true, y_pred),
            "F1": f1_score(y_true, y_pred),
            "Accuracy": accuracy_score(y_true, y_pred)
        }

        return self

    def _predict_profit(self, model, X: pd.DataFrame, y: pd.Series) -> float:
        """
        X: Pandas DataFrame with the data.
        y: Pandas Series with the target.
        Predicts the profit metric using the best model and custom threshold.
        """
        y_pred = model.predict(X)
        return self._profit(y, y_pred)

    def get_feature_importances(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        df: Pandas DataFrame with the data.
        Implements permutation feature importances on the data using the best model and custom threshold.
        """
    
        X = df.drop(self.target, axis=1)
        X = self.best_model_.steps[0][1].transform(X)
        y = df[self.target]
        result = permutation_importance(
            self.best_model_.steps[1][1],
            X,
            y,
            scoring=self._predict_profit,
            n_repeats=30,
            random_state=42,
            n_jobs=self.njobs
        )
        feature_importances = pd.DataFrame({
            "Feature": X.columns,
            "Importance": result.importances_mean
        })
        self.feature_importances = feature_importances.sort_values("Importance", ascending=False)
        return self.feature_importances
    
    def rank_customers(self, df: pd.DataFrame) -> pd.Series:
        """
        df: Pandas DataFrame with the data.
        Ranks the customers by their probability of insatisfaction.
        """
        df_ = df.copy()
        X = df_.drop(self.target, axis=1)
        y = df_[self.target]

        def apply_rank(x: float) -> int:
            """
            x: Probability of insatisfaction.
            Applies the rank (1 to 5) to the probability of insatisfaction.
            """
            thresholds = [c * self.threshold / 4 for c in range(5)][::-1]
            for rank, threshold in enumerate(thresholds):
                if x >= threshold:
                    return rank + 1
            return 5

        df_["rank"] = self.predict_proba(X)
        return df_["rank"].apply(apply_rank)
    
def build_model(path: str = None, train_df: pd.DataFrame = None, model: Pipeline = None,
                param_grid: dict = None, target: str = None,
                njobs: int = 8, verbose: bool = True) -> TrainEvaluate:
    """
    path: Path to a fitted model.
    train_df: Pandas DataFrame with the training data.
    model: sklearn Pipeline with the model.
    param_grid: Dictionary of parameters to search over.
    target: Name of the column to predict.
    njobs: Number of jobs to run in parallel.
    verbose: Wheter to print the progress or not.
    Builds a TrainEvaluate object.
    """

    train_evaluate = TrainEvaluate(model, param_grid, target, njobs, verbose)
    train_evaluate = train_evaluate.fit(train_df)
    with open(path, "wb") as f:
        pickle.dump(train_evaluate, f)
    return train_evaluate
Source: 00-support.ipynb

Além disso, um scipt para construção dos pipelines de processamento de dados foi criado para facilitar a manutenção e reutilização de código.

Mostrar/esconder código
# Este código é apenas uma reprodução do script original.
# --- Transformers --- #
from sklearn.impute import SimpleImputer, KNNImputer
from sklearn.decomposition import PCA
from sklearn.preprocessing import \
    StandardScaler, \
    OneHotEncoder
from resources.customtransformers import \
    DropConstantColumns, \
    DropDuplicateColumns, \
    AddNonZeroCount, \
    CustomSum, \
    CustomImputer, \
    AddNoneCount, \
    CustomEncoder, \
    CustomLog

# --- Pipeline Building --- #
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer

def build_prep() -> Pipeline:
    """
    Builds base pipeline.
    """
    prep = Pipeline(
        steps=[
            (
                "DropConstantColumns",
                DropConstantColumns(also=["ID"])
            ),
            (
                "DropDuplicateColumns",
                DropDuplicateColumns()
            ),
            (
                "NoneZeroCountSaldo",
                AddNonZeroCount(prefix="saldo")
            ),
            (
                "SumSaldo",
                CustomSum(prefix="saldo")
            ),
            (
                "NoneZeroCountImp",
                AddNonZeroCount(prefix="imp")
            ),
            (
                "SumImp",
                CustomSum(prefix="imp")
            ),
            (
                "ImputeNanDelta",
                CustomImputer(prefix="delta", to_replace=9999999999)
            ),
            (
                "NoneCountDelta",
                AddNoneCount(prefix="delta")
            ),
            (
                "NonZeroCountDelta",
                AddNonZeroCount(prefix="delta")
            ),
            (
                "SumDelta",
                CustomSum(prefix="delta")
            ),
            (
                "NonZeroContInd",
                AddNonZeroCount(prefix="ind")
            ),
            (
                "NonZeroCountNum",
                AddNonZeroCount(prefix="num")
            ),
            (
                "SumNum",
                CustomSum(prefix="num")
            ),
            (
                "ImputeNanVar3",
                CustomImputer(prefix="var3", to_replace=-999999)
            ),
            (
                "CustomEncoderVar36",
                CustomEncoder(colname="var36")
            ),
            (
                "CustomEncoderVar21",
                CustomEncoder(colname="var21")
            )
        ]
    )
    return prep

def build_prep_2() -> Pipeline:
    prep = Pipeline(
        steps=[
            ("prep", build_prep()),
            ("NoneCountVar3", AddNoneCount(prefix="var3")),
            ("drop_almost", DropConstantColumns(thresh=.99, ignore_prefix=["ind"])),
            ("nan", SimpleImputer(strategy="median"))
        ]
    )
    return prep

def build_prep_3(n_comp=None) -> Pipeline:
    log_cols = [
        'var3',
        'saldo_var30',
        'saldo_var42',
        'saldo_medio_var5_hace2',
        'saldo_medio_var5_hace3',
        'saldo_medio_var5_ult1',
        'saldo_medio_var5_ult3',
        'num_var42_0',
        'sum_of_saldo',
        'var38',
        'sum_of_num',
        'non_zero_count_num',
        'non_zero_count_ind'
    ]
    
    cat_cols = ["var36"]

    cat_tf = Pipeline(
        steps=[
            ("ohe", OneHotEncoder(min_frequency=100, sparse_output=False)),
        ]
    )

    prep = Pipeline(
        steps=[
            ("prep", build_prep()[:-2]),
            ("drop_almost", DropConstantColumns(thresh=.4, search=0)),
            ("log", CustomLog(columns = log_cols)),
            ("cat",ColumnTransformer([("ohe", cat_tf, cat_cols)], remainder='passthrough')),
            ("ss", StandardScaler()),
            ("knn", KNNImputer(n_neighbors=5)),
            ("pca", PCA(n_components=n_comp))
        ]
    )

    return prep
Source: 00-support.ipynb

Random Forest

O primeiro modelo testado foi o Random Forest. Após validação cruzada com GridSearchCV, o pipeline de classificação foi o seguinte:

Mostrar/esconder código
with open("models/rf.pkl", "rb") as f:
    rf = pickle.load(f)

rf.best_model_
Pipeline(steps=[('preprocessor',
                 Pipeline(steps=[('prep',
                                  Pipeline(steps=[('DropConstantColumns',
                                                   DropConstantColumns(also=['ID'])),
                                                  ('DropDuplicateColumns',
                                                   DropDuplicateColumns()),
                                                  ('NoneZeroCountSaldo',
                                                   AddNonZeroCount(prefix='saldo')),
                                                  ('SumSaldo',
                                                   CustomSum(prefix='saldo')),
                                                  ('NoneZeroCountImp',
                                                   AddNonZeroCount(prefix='imp')),
                                                  ('SumImp',
                                                   CustomSum(prefix='...
                                                   CustomEncoder(colname='var36')),
                                                  ('CustomEncoderVar21',
                                                   CustomEncoder(colname='var21'))])),
                                 ('NoneCountVar3', AddNoneCount(prefix='var3')),
                                 ('drop_almost',
                                  DropConstantColumns(ignore_prefix=['ind'],
                                                      thresh=0.99)),
                                 ('nan', SimpleImputer(strategy='median'))])),
                ('classifier',
                 RandomForestClassifier(class_weight='balanced', max_depth=4,
                                        max_features=64, n_estimators=500,
                                        random_state=42))])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Source: 02-class.ipynb

Percebe-se que foi adicionado um passo para remover variáveis quase constantes. Por mais que pudessem agregar poder de discriminação, inflar o modelo com dados esparsos – além de ser custoso computacionalmente – obriga que o número de variáveis sorteadas por split seja aumentado para que o modelo consiga selecionar variáveis relevantes, o que pode levar a overfitting.

Mesmo assim, o modelo ainda precisou de um número muito alto de variáveis aleatórias para conseguir selecionar as relevantes, o que pode ter prejudicado sua performance no conjunto de testes. Numa futura iteração deste estudo, o thresh do descartador de colunas poderia ser testado junto com os demais hiperparâmetros – outra vantagem de trabalhar com transformadores customizados.

Com número de estimadores fixado em 500 e class_weight em balanced (para arcar com o desbalanceamento), foram testados os seguintes hiperparâmetros:

Mostrar/esconder código
rf.param_grid
{'classifier__max_depth': [4, 8, 16, 32],
 'classifier__max_features': [8, 16, 32, 64]}
Source: 02-class.ipynb

Sendo que o algoritmo que executou o treinamento foi o seguinte:

Mostrar/esconder código
# Este código é apenas uma reprodução do script original.
import pandas as pd
from resources.prep import build_prep_2
from resources.train_evaluate import build_model
from sklearn.pipeline import Pipeline
from sklearn.ensemble import RandomForestClassifier

print("Training RandomForestClassifier...")

train = pd.read_csv("data/train.csv")

rf = Pipeline(
    steps=[
        ("preprocessor", build_prep_2()),
        (
            "classifier",
            RandomForestClassifier(
                random_state=42,
                n_estimators=500,
                class_weight="balanced"
            )
        )
    ]
)

rf_grid = {
    "classifier__max_depth": [4, 8, 16, 32],
    "classifier__max_features": [8, 16, 32, 64],
}

rf_model = build_model(
    path = "models/rf.pkl",
    train_df = train,
    model = rf,
    param_grid = rf_grid,
    target = "TARGET",
    njobs = 8,
    verbose = True
)

print("RandomForestClassifier trained. Model saved to models/rf.pkl")
Source: 00-support.ipynb

Histogram Based Gradient Boosting

O segundo modelo testado foi a implementação do Gradient Boosting de árvores baseado em histogramas do sklearn. Essa implementação é inspirada no LightGBM e foi escolhida apenas por consistência.

Mostrar/esconder código
with open("models/hgb.pkl", "rb") as f:
    hgb = pickle.load(f)

hgb.best_model_
Pipeline(steps=[('preprocessor',
                 Pipeline(steps=[('DropConstantColumns',
                                  DropConstantColumns(also=['ID'])),
                                 ('DropDuplicateColumns',
                                  DropDuplicateColumns()),
                                 ('NoneZeroCountSaldo',
                                  AddNonZeroCount(prefix='saldo')),
                                 ('SumSaldo', CustomSum(prefix='saldo')),
                                 ('NoneZeroCountImp',
                                  AddNonZeroCount(prefix='imp')),
                                 ('SumImp', CustomSum(prefix='imp')),
                                 ('ImputeNanDelta'...
                                 ('ImputeNanVar3',
                                  CustomImputer(prefix='var3',
                                                to_replace=-999999)),
                                 ('CustomEncoderVar36',
                                  CustomEncoder(colname='var36')),
                                 ('CustomEncoderVar21',
                                  CustomEncoder(colname='var21'))])),
                ('classifier',
                 HistGradientBoostingClassifier(categorical_features=['var36',
                                                                      'var21'],
                                                class_weight='balanced',
                                                l2_regularization=10,
                                                learning_rate=0.03, max_depth=4,
                                                random_state=42))])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Source: 02-class.ipynb

Para este classificador, foram testados os seguintes hiperparâmetros:

Mostrar/esconder código
hgb.param_grid
{'classifier__learning_rate': [0.01, 0.03, 0.1],
 'classifier__max_iter': [100, 150, 200],
 'classifier__max_depth': [3, 4, 5, 6],
 'classifier__l2_regularization': [1, 3, 10]}
Source: 02-class.ipynb

Sendo que o algoritmo que executou o treinamento foi o seguinte:

Mostrar/esconder código
# Este código é apenas uma reprodução do script original.
import pandas as pd
from resources.prep import build_prep
from resources.train_evaluate import build_model
from sklearn.pipeline import Pipeline
from sklearn.ensemble import HistGradientBoostingClassifier as HGB

print("Training HistGradientBoostingClassifier...")

df = pd.read_csv("data/train.csv")

hgb = Pipeline(
    steps=[
        ("preprocessor", build_prep()),
        (
            "classifier",
            HGB(
                random_state=42,
                class_weight="balanced",
                categorical_features=["var36", "var21"]
            )
        )
    ]
)

hgb_grid = {
    "classifier__learning_rate": [.01, .03, .1],
    "classifier__max_iter": [100, 150, 200],
    "classifier__max_depth": [3, 4, 5, 6],
    "classifier__l2_regularization": [1, 3, 10]
}

hgbc_model = build_model(
    path = "models/hgb.pkl",
    train_df = df,
    model = hgb,
    param_grid = hgb_grid,
    target = "TARGET",
    njobs = 8,
    verbose = True
)

print("Done training HistGradientBoostingClassifier. Model saved to models/hgb.pkl")
Source: 00-support.ipynb

Comparação entre os modelos

Em termos de machine learning, a performance dos modelos é dada por:

Mostrar/esconder código
business_results = pd.DataFrame(
    data = [
        rf.classification_metrics,
        hgb.classification_metrics
    ],
    index = [
        "Random Forest",
        "Histogram-based Gradient Boosting"
    ]
)

business_results.transpose().applymap(lambda x: f"{x:,.4f}")
Random Forest Histogram-based Gradient Boosting
Classification Threshold 0.5892 0.6779
ROC AUC 0.8343 0.8433
Precision 0.1579 0.1698
Recall 0.5931 0.6037
F1 0.2494 0.2650
Accuracy 0.8587 0.8675
Source: 02-class.ipynb

Já em termos de negócios:

Mostrar/esconder código
rf = rf.evaluate(test)
hgb = hgb.evaluate(test)

business_results = pd.DataFrame(
    data = [
        rf.business_metrics,
        hgb.business_metrics
    ],
    index = [
        "Random Forest",
        "Histogram-based Gradient Boosting"
    ]
)

business_results.transpose().applymap(lambda x: f"R${x:,.2f}")
Random Forest Histogram-based Gradient Boosting
Profit (Total) R$16,350.00 R$18,660.00
Profit (per Customer) R$0.86 R$0.98
True Positive Profit (Total) R$40,140.00 R$40,860.00
True Positive Profit (per Customer) R$2.11 R$2.15
False Positive Loss (Total) R$23,790.00 R$22,200.00
False Positive Loss (per Customer) R$1.25 R$1.17
False Negative Potential Profit Loss (Total) R$27,540.00 R$26,820.00
False Negative Potential Profit Loss (per Customer) R$1.45 R$1.41
True Negative Loss Prevention (Total) R$158,740.00 R$160,330.00
True Negative Loss Prevention (per Customer) R$8.35 R$8.44
Source: 02-class.ipynb

O modelo campeão é aquele que maximiza o lucro resultante da campanha, que é o objetivo do projeto. Assim, o modelo campeão é o HGB. A malha de hiperparâmetros passada para o HBG foi muito maior (11 vezes mais combinações), de modo que a comparação poderia ser considerada injusta. Contudo, é preciso considerar também o custo computacionalde cada modelo. Com o HGB discretizando as variáveis numéricas, a quantidade de splits testados é drasticamente reduzida, o que acelera o treinamento. Num cenário real onde treinar os modelos também acarreta custo, o HGB é ainda mais vantajoso.

Avaliação das features do modelo campeão

Todas as features positivas

Como se pode perceber, foram poucas as variáveis que contribuíram positivamente para o modelo. Uma próxima etapa no desenvolvimento do modelo antes da implementação seria utilizar apenas estas variáveis, eliminando ruído e reduzindo o custo computacional envolvido. O modelo de Random Forest principalmente se beneficiaria muito desta redução, dada a sua natureza aleatória de sortear variáveis em cada split de cada árvore.

Mostrar/esconder código
feature_importances = hgb \
    .get_feature_importances(test) \
    .reset_index(drop=True)

feature_importances[feature_importances['Importance'] > 0]
Feature Importance
0 var15 31810.666667
1 saldo_var30 17984.666667
2 num_var45_hace3 2498.666667
3 saldo_var5 2304.333333
4 var38 2224.666667
5 sum_of_num 1405.333333
6 saldo_medio_var5_ult3 1108.333333
7 num_meses_var5_ult3 968.666667
8 saldo_medio_var5_hace2 955.333333
9 sum_of_saldo 756.666667
10 saldo_medio_var5_hace3 683.000000
11 num_var22_hace3 518.333333
12 num_var42_0 382.666667
13 var3 348.000000
14 num_op_var41_ult1 203.666667
15 imp_op_var39_ult1 118.333333
16 saldo_medio_var5_ult1 112.000000
17 num_op_var41_ult3 107.333333
18 saldo_var42 100.666667
19 num_op_var39_ult1 94.666667
20 imp_op_var41_efect_ult1 88.333333
21 imp_op_var39_efect_ult1 85.000000
22 sum_of_imp 77.000000
23 num_var22_ult3 77.000000
24 non_zero_count_ind 49.666667
25 num_var22_hace2 45.333333
26 imp_op_var41_ult1 41.333333
27 imp_op_var41_comer_ult3 20.666667
28 ind_var30_0 12.666667
29 num_med_var45_ult3 7.666667
30 imp_op_var41_comer_ult1 4.666667
31 saldo_medio_var12_hace2 2.666667
32 num_var26 2.333333
33 ind_var8_0 1.666667
34 saldo_var14 0.333333
35 saldo_var8 0.333333
Source: 02-class.ipynb

Features criadas

Percebe-se que de modo geral as features criadas contribuíram positivamente para o modelo, mostrando a importância da análise exploratória para o desenvolvimento do modelo mesmo sem conhecimento do que cada variável original representa.

Algumas variáveis criadas se mostraram ruidosas, indicando que o modelo pode ter sido sobreajustado. Este comportamento também se repetiu em diversas variáveis originais (vide o documento original da classificação). Reduzir o número de features poderia ser uma solução, mas também seria interessante testar uma malha de hiperparâmetros com penalizações mais severas, principalmente considerando que a maior penalização testada foi a selecionada no GridSearchCV.

Mostrar/esconder código
feature_importances \
    .where(
        lambda ldf:
        ldf["Feature"].apply(
            lambda row:
            (
                row.startswith("none_")
                | row.startswith("sum_")
                | row.startswith("non_")
            )
        )
    ) \
    .dropna()
Feature Importance
5 sum_of_num 1405.333333
9 sum_of_saldo 756.666667
22 sum_of_imp 77.000000
24 non_zero_count_ind 49.666667
55 non_zero_count_imp 0.000000
68 non_zero_count_delta 0.000000
90 none_count_delta 0.000000
290 sum_of_delta -24.000000
291 non_zero_count_saldo -24.333333
296 non_zero_count_num -62.000000
Source: 02-class.ipynb

Rankeamento de clientes

Usando o mesmo modelo de HGB carregado anteriormente, os clientes foram rankeados com base na probabilidade de insatisfação. O corte de classificação do rank 1 é o mesmo do modelo em si, enquanto os demais são steps iguais entre 0 e o corte original.

Mostrar/esconder código
test \
    .copy() \
    .assign(predicted=hgb.predict(test.drop("TARGET",axis=1))) \
    .assign(probability=hgb.predict_proba(test.drop("TARGET",axis=1))) \
    .assign(
        profit=(
            lambda ldf:
            ((ldf["TARGET"] * 100) - 10) * ldf["predicted"]
        )
    ) \
    .assign(rank=hgb.rank_customers(test)) \
    [["rank","profit","probability"]] \
    .groupby("rank") \
    .agg({
        "profit": ["mean","sum","count"],
        "probability": ["min"]
    }) \
    .droplevel(0,axis=1) \
    .rename(
        mapper={
            "mean": "avg_profit",
            "sum": "total_profit",
            "count": "total_customers",
            "min": "min_probability"
        },
        axis=1
    )
avg_profit total_profit total_customers min_probability
rank
1 6.97831 18660 2674 0.678104
2 0.00000 0 1921 0.508453
3 0.00000 0 3447 0.339087
4 0.00000 0 5958 0.169471
5 0.00000 0 5005 0.047873
Source: 02-class.ipynb

3 - Análise de agrupamentos

Antes realizar qualquer tipo de análise, recuperamos o modelo de classificação para atribuir o lucro de cada cliente da base de testes, mas concatenamos o conjunto de testes com o conjunto de treino, preenchendo o lucro de cada cliente deste com np.nan para que forneçam volume nos dados mas não causem viés na análise.

Mostrar/esconder código
with open("models/hgb.pkl", "rb") as f:
    hgb = pickle.load(f)

test = pd \
    .read_csv('data/test.csv') \
    .assign(
        predicted = (
            lambda ldf:
            hgb.predict(ldf.drop("TARGET", axis=1))
        ),
        profit = (
            lambda ldf:
            ((ldf["TARGET"] * 100) - 10) * ldf["predicted"]
        ),
        origin = "test"
    )

train = pd \
    .read_csv('data/train.csv') \
    .assign(
        predicted = np.nan,
        profit = np.nan,
        origin = "train"
    )

df = pd.concat([train, test]).reset_index(drop=True)

reference = df[["ID", "TARGET", "predicted", "profit", "origin"]]

pd.concat([reference.head(3), reference.tail(3)]).head(6)
ID TARGET predicted profit origin
0 113911 0 NaN NaN train
1 120462 0 NaN NaN train
2 87126 0 NaN NaN train
76017 138601 1 0.0 0.0 test
76018 78655 0 1.0 -10.0 test
76019 130139 0 1.0 -10.0 test
Source: 03-cluster.ipynb

Processamento dos dados

Como o conjunto é dado por variáveis numéricas, os algorítmos usados serão agrupamentos baseados em distância. Sem conhecimento do que realmente é cada variável, não é possível tomar decisões baseadas em conhecimento da área.

A primeira etapa para determinar o processamento dos dados e sua preparação para a clusterização foi recuperar o pipeline de processamento de dados usado na classificação e aplicá-lo novamente.

Mostrar/esconder código
pdf = df.drop(["TARGET","predicted","profit","origin"], axis=1)
prep = build_prep()[:-2].fit(pdf)
pdf = prep.transform(pdf)
prep
Pipeline(steps=[('DropConstantColumns', DropConstantColumns(also=['ID'])),
                ('DropDuplicateColumns', DropDuplicateColumns()),
                ('NoneZeroCountSaldo', AddNonZeroCount(prefix='saldo')),
                ('SumSaldo', CustomSum(prefix='saldo')),
                ('NoneZeroCountImp', AddNonZeroCount(prefix='imp')),
                ('SumImp', CustomSum(prefix='imp')),
                ('ImputeNanDelta',
                 CustomImputer(prefix='delta', to...99999)),
                ('NoneCountDelta', AddNoneCount(prefix='delta')),
                ('NonZeroCountDelta', AddNonZeroCount(prefix='delta')),
                ('SumDelta', CustomSum(prefix='delta')),
                ('NonZeroContInd', AddNonZeroCount(prefix='ind')),
                ('NonZeroCountNum', AddNonZeroCount(prefix='num')),
                ('SumNum', CustomSum(prefix='num')),
                ('ImputeNanVar3',
                 CustomImputer(prefix='var3', to_replace=-999999))])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Source: 03-cluster.ipynb

Em seguida, foi avaliada a esparsidade das features. A tabela abaixo mostra a quantidade de colunas em cada faixa de concentração (%) de valores iguais a zero.

Mostrar/esconder código
npz = neg_pos_zero(pdf, list(pdf.columns))
npz \
    .assign(
        zero_zone = lambda ldf: ldf["Zero values (%)"].apply(lambda lr: lr//10 * 10)
    ) \
    [["zero_zone", "Column"]] \
    .groupby("zero_zone") \
    .count() \
    .rename(mapper={"Column": "Number of Columns"}, axis=1)
Number of Columns
zero_zone
0.0 12
10.0 5
20.0 10
30.0 7
50.0 1
60.0 4
70.0 4
80.0 26
90.0 247
Source: 03-cluster.ipynb

Como a faixa de 40% não possui nenhuma variável, este valor foi definido para reduzir o número de variáveis do modelo, permitindo uma análise mais minusciosa das restantes.

Mostrar/esconder código
ss = StandardScaler()

pdft = pd.concat(
    [
        pd.DataFrame(
            ss.fit_transform(pdf),
            columns=pdf.columns
        ),
        reference
    ],
    axis=1
)

long_df = pdft \
    .melt(
        id_vars="TARGET",
        value_vars=remaining,
        var_name="variable",
        value_name="value"
    )

plt.figure(figsize=(10, 30))
sns.violinplot(
    long_df,
    x="value",
    y="variable",
)
plt.show()

Source: 03-cluster.ipynb

Componentes Principais

Após a seleção e análise das variáveis restantes, foi construído o seguinte pipeline de processamento de dados:

Mostrar/esconder código
cdf = df.drop(["TARGET","predicted","profit","origin"],axis=1)
prep = build_prep_3().fit(cdf)
tdf = pd.concat(
    [
        pd.DataFrame(prep.transform(cdf)),
        reference
    ],
    axis=1
)
prep
Pipeline(steps=[('prep',
                 Pipeline(steps=[('DropConstantColumns',
                                  DropConstantColumns(also=['ID'])),
                                 ('DropDuplicateColumns',
                                  DropDuplicateColumns()),
                                 ('NoneZeroCountSaldo',
                                  AddNonZeroCount(prefix='saldo')),
                                 ('SumSaldo', CustomSum(prefix='saldo')),
                                 ('NoneZeroCountImp',
                                  AddNonZeroCount(prefix='imp')),
                                 ('SumImp', CustomSum(prefix='imp')),
                                 ('ImputeNanDelta',
                                  CustomI...
                                    'saldo_medio_var5_ult1',
                                    'saldo_medio_var5_ult3', 'num_var42_0',
                                    'sum_of_saldo', 'var38', 'sum_of_num',
                                    'non_zero_count_num',
                                    'non_zero_count_ind'])),
                ('cat',
                 ColumnTransformer(remainder='passthrough',
                                   transformers=[('ohe',
                                                  Pipeline(steps=[('ohe',
                                                                   OneHotEncoder(min_frequency=100,
                                                                                 sparse_output=False))]),
                                                  ['var36'])])),
                ('ss', StandardScaler()), ('knn', KNNImputer()),
                ('pca', PCA())])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Source: 03-cluster.ipynb

Sendo o scree plot do PCA:

Mostrar/esconder código
expl_var(prep[-1].explained_variance_ratio_)

80% of variance is explained by 9 components
Source: 03-cluster.ipynb

É importante ressaltar que embora a primeira componente explique muito bem a variabilidade do conjunto, foi necessário manter 9 componentes principais para que a variância explicada chegasse a 80% (valor comumente utilizado neste tipo de análise). Assim, a visualização dos dados não é tão simples.

Abaixo, a distribuição das 3 componentes principais:

Mostrar/esconder código
long_df = tdf \
    .melt(
        id_vars="TARGET",
        value_vars=[0, 1, 2],
        var_name="variable",
        value_name="value"
    )

sns.violinplot(
    long_df,
    x="variable",
    y="value",
)
plt.show()

Source: 03-cluster.ipynb

No gráfico de dispersão, vemos a distribuição da variável de interesse nas 3 componentes principais.

Mostrar/esconder código
px.scatter_3d(
    tdf.assign(TARGET=tdf["TARGET"].astype("category")),
    x=0,
    y=1,
    z=2,
    color="TARGET",
    opacity=.1
)
Source: 03-cluster.ipynb

Agrupamentos com K-Means

Datas as distribuições das 3 componentes principais, optou-se por utilizar o K-Means como algoritmo de agrupamento. Abaixo, temos o gráfico de cotovelo para determinar o número de clusters.

Mostrar/esconder código
ssd = []
for num_clusters in range(1, 31):
    kmeans = KMeans(n_clusters=num_clusters, random_state=42, n_init='auto')
    kmeans.fit(tdf[[i for i in range(0,10)]])
    ssd.append(kmeans.inertia_)


plt.figure(figsize=(10,6))
plt.plot(range(1, 31), ssd, marker='o', linestyle='--')
plt.xlabel('Number of Clusters')
plt.ylabel('Sum of Squared Distances')
plt.title('Elbow Method For Optimal Number of Clusters')
plt.show()

Source: 03-cluster.ipynb

Determinando o número de clusters como 9, temos a distribuição dos clientes nos clusters:

Mostrar/esconder código
kmeans = KMeans(n_clusters=9, random_state=42, n_init='auto')
clusters = kmeans.fit_predict(tdf[[i for i in range(0,10)]])
tdf["kmeans"] = clusters

px.scatter_3d(
    tdf.assign(kmeans=tdf["kmeans"].astype("category")),
    x=0,
    y=1,
    z=2,
    color="kmeans",
    opacity=.1
)
Source: 03-cluster.ipynb

Com os grupos ordenados por lucro, temos:

Mostrar/esconder código
gdf = tdf[["kmeans", "profit", "TARGET"]] \
    .groupby(["kmeans"]) \
    .agg(["mean", "sum", "count"])

gdf.columns = [
    "Average Profit",
    "Total Profit",
    "Number of Customers (Test)",
    "drop0",
    "drop1",
    "Number of Customers (Total)"
]

gdf \
    .drop(["drop0", "drop1"], axis=1) \
    .sort_values("Average Profit", ascending=False)
Average Profit Total Profit Number of Customers (Test) Number of Customers (Total)
kmeans
5 4.137623 4630.0 1119 4454
4 2.860114 11000.0 3846 15372
7 1.495935 920.0 615 2423
8 0.550459 60.0 109 406
6 0.425601 1310.0 3078 12306
1 0.104572 780.0 7459 29807
0 0.000000 0.0 1318 5465
2 0.000000 0.0 419 1633
3 -0.038388 -40.0 1042 4154
Source: 03-cluster.ipynb

Considerações finais

Este estudo foi realizado com o objetivo de maximizar o lucro de uma campanha de retenção. Para isso, foram realizadas análises exploratórias, desenvolvidos modelos de classificação e rankeamento e realizada uma análise de agrupamentos naturais. Com conhecimento de mercado, seria possível usar os clusters para direcionar ações específicas para cada grupo de clientes, usando o modelo de classificação para determinar quais estariam de fato insatisfeitos. Nenhum modelo aqui estaria pronto para a produção e outros passos (como feature selection mais rigoroso e análise exploratória mais cuidadosa) poderiam ser adicionados para garantir a robustez do modelo final. Foi possível criar um modelo de classificação lucrativo e encontrar agrupamentos bem definidos, ainda criando margens de observação para clientes possivelmente insatisfeitos com base no rankeamento. Agradeço os envolvidos no processo pela oportunidade de participar do Data Masters e espero que este estudo gere boas discussões acerca do tema.

Felipe Viacava